Debounce와 Throttle을 선택하는 기준

Debounce와 Throttle을 선택하는 기준

한눈에 보기

Debounce는 연속 이벤트가 멈춘 뒤 최종 의도를 처리하고, Throttle은 이벤트가 계속되는 동안 일정 빈도로 중간 상태를 처리한다. 둘 다 호출 수를 줄이지만 사용자에게 보이는 지연과 보존하는 정보가 다르다. 타이머만 줄일 것이 아니라 예약 작업 취소, 마지막 호출 보장, 오래된 네트워크 응답 무시까지 함께 설계해야 한다.

검색창에 한 글자를 입력할 때마다 API를 호출하거나 스크롤 이벤트마다 레이아웃을 다시 계산하면 불필요한 작업이 빠르게 늘어난다. 이때 흔히 debounce와 throttle을 사용한다.

const search = debounce(fetchResults, 300);
const updateScroll = throttle(renderProgress, 100);

코드는 비슷해 보이지만 의미는 다르다. 잘못 선택하면 서버 요청 수는 줄어도 검색 결과가 지나치게 늦게 나타나거나, 드래그가 끊기거나, 마지막 상태가 화면에 반영되지 않는다.

이 글에서는 구현 문법보다 다음 질문을 중심으로 두 기법을 비교한다.

목차

먼저 이벤트의 시간축을 본다

사용자가 80ms 간격으로 여섯 번 입력했다고 가정하자.

이벤트  e1---e2---e3---e4---e5---e6----------------
시간    0    80   160  240  320  400             ms

300ms trailing debounce는 마지막 이벤트 이후 조용한 시간이 300ms 이어졌을 때 한 번 실행한다.

이벤트  e1---e2---e3---e4---e5---e6----------------
실행    -----------------------------------D--------
                                            700ms

200ms throttle은 이벤트가 계속되는 동안 최대 200ms에 한 번 정도 실행한다. leading과 trailing 설정에 따라 정확한 시점은 달라진다.

이벤트  e1---e2---e3---e4---e5---e6----------------
실행    T-------------T-------------T---------------
        0            200           400

둘의 차이는 단순한 호출 횟수가 아니다.

질문 Debounce Throttle
보존하려는 것 연속 입력의 마지막 의도 진행 중인 상태의 표본
실행 시점 이벤트가 멈춘 뒤 이벤트가 계속돼도 일정 간격
대표 사례 검색어, 자동 저장, validation 스크롤 진행률, pointer 이동, resize 관측
사용자가 느끼는 비용 결과 시작이 늦어짐 중간 상태가 성기게 보임
마지막 값 보장 trailing 설정 필요 trailing 설정에 따라 달라짐
선택 기준

“이 이벤트가 끝났다는 것을 기다려야 의미가 생기는가?”라면 debounce, “계속되는 동안에도 현재 상태를 보여야 하는가?”라면 throttle을 먼저 고려한다.

Debounce는 멈춤을 기다린다

Debounce는 호출될 때마다 기존 타이머를 취소하고 새로 예약한다.

type AnyFunction = (...args: never[]) => void;

export function debounce<T extends AnyFunction>(
  callback: T,
  waitMs: number,
) {
  let timerId: ReturnType<typeof setTimeout> | undefined;

  return (...args: Parameters<T>) => {
    if (timerId !== undefined) {
      clearTimeout(timerId);
    }

    timerId = setTimeout(() => {
      callback(...args);
      timerId = undefined;
    }, waitMs);
  };
}

구조를 설명하기 위한 단순 예시다. 실제 공용 유틸리티라면 this 보존, 반환값, leading, trailing, maxWait, cancel, flush 타입까지 결정해야 한다. 검증된 라이브러리를 사용하는 편이 나을 수 있다.

검색 입력에 적용하면 마지막 키 입력이 끝난 뒤 요청한다.

const searchInput =
  document.querySelector<HTMLInputElement>("#search");

const requestResults = debounce((keyword: string) => {
  void fetchResults(keyword);
}, 300);

searchInput?.addEventListener("input", (event) => {
  const target = event.currentTarget;
  requestResults(target.value.trim());
});

Debounce가 잘 맞는 경우는 이벤트 중간값보다 최종값이 중요한 작업이다.

하지만 wait 시간이 길면 사용자가 매번 그만큼 기다린다. 서버 부하를 줄이는 값과 사용자에게 자연스러운 지연 사이의 균형을 실제 입력 흐름에서 확인해야 한다.

끝없이 연기될 수 있다

이벤트가 wait보다 짧은 간격으로 계속 발생하면 trailing debounce는 영원히 실행되지 않을 수 있다. 자동 저장처럼 일정 시간 안에는 반드시 실행해야 한다면 maxWait가 필요하다.

Throttle은 흐름 중간을 샘플링한다

Throttle은 일정 시간 창 안에서 실행 횟수를 제한한다. 가장 단순한 timestamp 기반 leading throttle은 다음과 같다.

export function throttle<T extends (...args: never[]) => void>(
  callback: T,
  intervalMs: number,
) {
  let lastExecutedAt = -Infinity;

  return (...args: Parameters<T>) => {
    const now = performance.now();

    if (now - lastExecutedAt < intervalMs) {
      return;
    }

    lastExecutedAt = now;
    callback(...args);
  };
}

이 구현은 첫 이벤트를 즉시 처리하지만 interval 안에 들어온 마지막 이벤트를 버린다.

스크롤 진행률에서 사용하면 움직이는 동안 결과가 주기적으로 보인다.

const updateProgress = throttle(() => {
  const scrollable =
    document.documentElement.scrollHeight - window.innerHeight;
  const ratio =
    scrollable <= 0 ? 1 : window.scrollY / scrollable;

  progressBar.style.transform = `scaleX(${ratio})`;
}, 100);

window.addEventListener("scroll", updateProgress, {
  passive: true,
});

사용자가 스크롤을 멈춘 정확한 마지막 위치까지 반영하려면 trailing 호출을 지원하거나 별도의 종료 처리가 필요하다.

Throttle이 잘 맞는 경우는 진행 과정의 관측이 필요한 작업이다.

관측 빈도가 낮으면 UI가 끊겨 보이고, 높으면 최적화 효과가 작다. 16ms, 100ms 같은 숫자를 관습적으로 복사하지 말고 화면 갱신인지 네트워크 전송인지에 따라 정한다.

Leading과 trailing을 결정하기

두 기법 모두 첫 호출과 마지막 호출을 어떻게 처리할지 선택할 수 있다.

flowchart TD
    E["연속 이벤트 시작"] --> Q{"첫 결과가 즉시 필요한가?"}
    Q -- "예" --> L["leading 실행"]
    Q -- "아니오" --> W["대기"]
    L --> M{"마지막 상태도 필요한가?"}
    W --> M
    M -- "예" --> T["trailing 실행"]
    M -- "아니오" --> X["중간 호출만 제한"]
설정 동작 어울리는 사례
leading false, trailing true 멈춘 뒤 마지막 한 번 검색, validation
leading true, trailing false 시작 즉시 한 번 중복 클릭 방지와 비슷한 흐름
leading true, trailing true 시작과 마지막 보장 진행 상태와 최종 위치
maxWait 계속 호출돼도 최대 대기 후 실행 자동 저장, telemetry batch

버튼 제출 중복 방지는 debounce보다 버튼 disabled, 요청 멱등성, 서버 idempotency key가 더 직접적인 해결책일 수 있다. 시간 제어 유틸리티를 비즈니스 무결성 장치로 사용해서는 안 된다.

검색에서는 첫 글자를 즉시 요청하는 leading 방식이 오히려 의미 없을 수 있다. 반면 suggestion cache가 충분하고 첫 결과가 빨리 필요한 제품에서는 leading과 trailing을 함께 사용할 수도 있다.

Cancel과 flush가 필요한 이유

컴포넌트가 사라졌는데 예약된 callback이 실행되면 이미 폐기된 UI를 갱신하거나 불필요한 저장을 수행할 수 있다. 공용 debounce에는 최소한 cancel 동작을 고려한다.

type Debounced<T extends (...args: never[]) => void> = {
  (...args: Parameters<T>): void;
  cancel(): void;
};

export function cancellableDebounce<
  T extends (...args: never[]) => void,
>(
  callback: T,
  waitMs: number,
): Debounced<T> {
  let timerId: ReturnType<typeof setTimeout> | undefined;

  const debounced = (...args: Parameters<T>) => {
    if (timerId !== undefined) clearTimeout(timerId);

    timerId = setTimeout(() => {
      timerId = undefined;
      callback(...args);
    }, waitMs);
  };

  debounced.cancel = () => {
    if (timerId !== undefined) clearTimeout(timerId);
    timerId = undefined;
  };

  return debounced;
}

flush()는 대기 중인 마지막 작업을 즉시 실행하는 기능이다. 자동 저장 화면을 닫기 직전에 마지막 편집을 저장해야 한다면 유용할 수 있다. 다만 unload 시점 네트워크는 별도 제약이 있으므로 flush 하나로 전송 완료를 보장한다고 생각하면 안 된다.

예약 작업의 수명 주기를 먼저 정의한다.

검색 요청은 타이머와 네트워크를 함께 취소한다

Debounce는 시작 전 호출을 줄인다. 이미 시작된 fetch는 자동으로 취소하지 않는다.

다음 상황을 보자.

"rea" 요청 시작 ───────────────────── 응답 A
"react" 요청 시작 ───── 응답 B

나중 요청 B가 먼저 도착한 뒤 오래된 A가 도착하면 화면이 이전 검색어 결과로 돌아갈 수 있다. 요청 수를 줄였어도 응답 순서 역전 문제는 남는다.

React에서는 Effect의 cleanup과 AbortController를 함께 사용할 수 있다.

"use client";

import { useEffect, useState } from "react";

type SearchResult = {
  id: string;
  title: string;
};

export function SearchBox() {
  const [keyword, setKeyword] = useState("");
  const [results, setResults] = useState<SearchResult[]>([]);
  const [pending, setPending] = useState(false);

  useEffect(() => {
    const normalized = keyword.trim();

    if (normalized.length < 2) {
      setResults([]);
      setPending(false);
      return;
    }

    const controller = new AbortController();

    const timerId = window.setTimeout(async () => {
      setPending(true);

      try {
        const response = await fetch(
          `/api/search?q=${encodeURIComponent(normalized)}`,
          { signal: controller.signal },
        );

        if (!response.ok) {
          throw new Error(`Search failed: ${response.status}`);
        }

        const data = (await response.json()) as SearchResult[];
        setResults(data);
      } catch (error) {
        if (
          error instanceof DOMException &&
          error.name === "AbortError"
        ) {
          return;
        }

        reportSearchError(error);
      } finally {
        if (!controller.signal.aborted) {
          setPending(false);
        }
      }
    }, 300);

    return () => {
      window.clearTimeout(timerId);
      controller.abort();
    };
  }, [keyword]);

  return (
    <section>
      <label htmlFor="keyword">검색</label>
      <input
        id="keyword"
        value={keyword}
        onChange={(event) => setKeyword(event.target.value)}
      />
      {pending && <p role="status">검색 중</p>}
      <SearchResults items={results} />
    </section>
  );
}

cleanup은 다음 keyword Effect가 시작되기 전과 unmount 때 호출된다.

취소가 서버 작업 완료를 반드시 막는 것은 아니다. 요청이 서버에 도착한 뒤라면 서버가 계속 처리할 수 있다. 검색처럼 읽기 작업에는 주로 클라이언트 자원과 오래된 결과 반영을 막는 의미가 있다. 쓰기 작업에는 멱등성과 서버 측 취소 정책이 별도로 필요하다.

React에서 함수 인스턴스를 안정적으로 유지하기

렌더마다 debounce 함수를 새로 만들면 각 인스턴스가 별도 타이머를 가진다.

function SearchBox() {
  const search = debounce(fetchResults, 300);

  return (
    <input
      onChange={(event) => search(event.target.value)}
    />
  );
}

state 업데이트로 재렌더될 때 이전 인스턴스의 타이머를 새 인스턴스가 취소할 수 없다. 결과적으로 debounce가 풀린 것처럼 여러 callback이 실행될 수 있다.

선택지는 크게 두 가지다.

값의 변화를 Effect에서 지연한다

앞 절의 검색 예시처럼 원본 input state는 즉시 업데이트하고 Effect 안에서 타이머를 관리한다. 의존성과 cleanup 관계가 명시적이다.

debounce 인스턴스를 안정적으로 보관한다

외부 이벤트 API에 callback을 전달해야 한다면 useMemouseRef로 수명을 유지하고 unmount 때 취소한다.

function ResizeReporter({
  report,
}: {
  report: (width: number) => void;
}) {
  const debouncedReport = useMemo(
    () => cancellableDebounce(report, 250),
    [report],
  );

  useEffect(() => {
    const onResize = () => {
      debouncedReport(window.innerWidth);
    };

    window.addEventListener("resize", onResize);

    return () => {
      window.removeEventListener("resize", onResize);
      debouncedReport.cancel();
    };
  }, [debouncedReport]);

  return null;
}

부모가 report 함수를 매번 새로 만들면 debounce 인스턴스도 바뀐다. callback 안정성을 부모 계약으로 둘지, 최신 callback을 ref로 읽을지 결정해야 한다.

최신 값과 안정적인 함수는 다른 문제다

함수 identity를 고정하면서 오래된 props를 캡처하는 stale closure가 생길 수 있다. 안정적인 wrapper가 실행 시점에 어떤 값을 읽어야 하는지 테스트한다.

이 주제는 useEffect 의존성 배열을 거짓말하면 생기는 문제와 연결된다.

스크롤과 resize에는 requestAnimationFrame도 고려한다

화면의 스타일이나 위치를 갱신하는 작업은 고정된 16ms throttle보다 requestAnimationFrame이 브라우저 paint 주기에 맞는다.

let frameId: number | null = null;
let latestScrollY = 0;

function onScroll() {
  latestScrollY = window.scrollY;

  if (frameId !== null) return;

  frameId = window.requestAnimationFrame(() => {
    frameId = null;
    renderHeader(latestScrollY);
  });
}

window.addEventListener("scroll", onScroll, {
  passive: true,
});

이 패턴은 한 frame 안에서 여러 scroll 이벤트가 발생해도 최신 값으로 한 번만 렌더한다.

sequenceDiagram
    participant E as Scroll events
    participant Q as rAF queue
    participant P as Browser paint

    E->>Q: 최신 scrollY 기록
    E->>Q: 이미 예약됨, 추가 예약 안 함
    Q->>P: paint 직전 한 번 갱신

requestAnimationFrame은 대부분의 브라우저에서 background tab일 때 일시 중지된다. 화면 그리기에는 적절하지만 서버에 일정 주기로 telemetry를 보내는 용도로는 의미가 다르다.

용도별 선택은 다음과 같다.

작업 우선 고려
paint와 동기화된 DOM 갱신 requestAnimationFrame
일정 간격으로 분석 이벤트 샘플 time-based throttle
입력이 멈춘 뒤 검색 debounce
요소가 viewport에 들어왔는지 관측 IntersectionObserver
요소 크기 변화 관측 ResizeObserver

이벤트를 줄이기 전에 더 직접적인 브라우저 API가 있는지도 확인한다.

Debounce와 React의 지연 기능을 구분하기

useDeferredValuestartTransition은 debounce처럼 보일 수 있지만 같은 도구가 아니다.

Debounce는 일정 시간 동안 callback 자체를 실행하지 않는다. API 요청 수를 실제로 줄일 수 있다.

React의 transition과 deferred value는 urgent update와 덜 중요한 render update의 우선순위를 구분한다. 고정된 300ms를 기다리는 것이 아니며 중간 render가 중단될 수 있어도 데이터 요청이 자동으로 debounce되는 것은 아니다.

const [keyword, setKeyword] = useState("");
const deferredKeyword = useDeferredValue(keyword);

return (
  <>
    <input
      value={keyword}
      onChange={(event) => setKeyword(event.target.value)}
    />
    <ExpensiveLocalList keyword={deferredKeyword} />
  </>
);

이 예시는 입력 state를 즉시 반영하면서 비싼 로컬 목록 렌더를 뒤로 미룬다. 네트워크 요청 수 제한이 목적이라면 별도의 debounce 또는 서버 상태 도구의 정책이 필요하다.

목적 적합한 도구
callback 호출 횟수 제한 debounce / throttle
입력은 즉시, 비싼 render는 낮은 우선순위 deferred value / transition
이미 시작한 fetch 중단 AbortController
paint당 DOM 갱신 한 번 requestAnimationFrame

하나의 검색 UI에서 debounce와 deferred value를 함께 쓸 수도 있지만 각각 해결하는 비용을 구분해 측정해야 한다.

자주 발생하는 실패 패턴

wait 숫자를 근거 없이 복사한다

300ms가 모든 검색창에 적합한 것은 아니다. 로컬 필터인지 원격 검색인지, 평균 응답 시간이 얼마인지, 한국어 IME 조합을 어떻게 처리하는지에 따라 다르다.

마지막 이벤트가 사라진다

leading-only throttle은 사용자가 멈춘 최종 위치를 놓칠 수 있다. 저장이나 정확한 진행률에는 trailing 보장이 필요한지 확인한다.

unmount 뒤 예약 callback이 실행된다

타이머를 cleanup하지 않으면 사라진 컴포넌트의 작업이 실행된다. 개발 Strict Mode의 setup→cleanup→setup 흐름에서도 대칭적으로 동작해야 한다.

debounce만 하고 오래된 fetch는 취소하지 않는다

연속 요청 수는 줄지만 느린 이전 응답이 최신 결과를 덮을 수 있다. abort 또는 request sequence 검사가 필요하다.

let latestRequestId = 0;

async function search(keyword: string) {
  const requestId = ++latestRequestId;
  const result = await fetchResults(keyword);

  if (requestId !== latestRequestId) return;
  renderResults(result);
}

취소를 지원하지 않는 비동기 작업에는 최신 요청 ID 검사가 대안이 될 수 있다.

입력 state 자체를 늦춘다

controlled input의 value 업데이트까지 debounce하면 타이핑이 끊겨 보인다. 화면 입력 state는 즉시 반영하고 비싼 파생 작업만 지연한다.

rate limit과 혼동한다

클라이언트 throttle은 사용자가 코드를 우회할 수 있다. 서버 보호를 위한 rate limit은 서버나 gateway에서 별도로 적용한다.

측정과 선택 체크리스트

의미 선택

생명주기

사용자 경험

측정할 때는 callback 수만 세지 않는다.

호출 횟수가 절반으로 줄어도 사용자가 결과를 500ms 더 기다리게 됐다면 좋은 변경인지 제품 기준으로 판단해야 한다.

마무리

Debounce와 Throttle은 모두 빈번한 이벤트를 제어하지만 보존하는 의미가 다르다. Debounce는 조용한 구간을 기다려 마지막 의도를 처리한다. Throttle은 흐름을 유지하면서 일정 빈도로 중간 상태를 샘플링한다.

실무에서는 leading과 trailing, maxWait, cancel이 선택의 일부다. React에서는 렌더마다 새 wrapper를 만들지 않고 Effect cleanup으로 예약 작업을 정리해야 한다. 네트워크 요청이 시작됐다면 타이머 취소만으로는 부족하므로 AbortController나 최신 요청 검사를 사용한다. 화면 갱신이라면 시간 기반 throttle보다 requestAnimationFrame이 더 자연스러울 수 있다.

어떤 기법을 쓸지는 이벤트 이름이 아니라 버려도 되는 정보와 사용자가 기다릴 수 있는 시간을 기준으로 정한다. 호출 횟수 감소, 최종 상태의 정확성, 체감 지연을 함께 측정해야 실제 최적화가 된다.

관련 노트

참고 자료